S07-03 网络编程-项目:多用户即时通信系统
[TOC]
基于 Java SE 网络编程实现的多用户即时通信系统,采用 C/S 架构,覆盖客户端与服务端两侧。
技术栈:Java 8+、TCP Socket 长连接、对象流(
ObjectInputStream/ObjectOutputStream)传输Serializable对象、多线程并发、HashMap模拟数据库。
项目概述
项目背景
本项目以经典即时通信软件 QQ 为原型,使用 Java SE 基础网络编程技术(Socket + 多线程 + 对象流)实现一个支持多用户同时在线的命令行即时通信系统。项目旨在综合运用网络编程、多线程并发、对象序列化等知识点,作为 Java 网络编程阶段的综合实战。
目标用户
- 学习 Java 网络编程的开发者,作为教学与练习项目。
- 需要在局域网/本机进行多用户文字通信与文件传输的轻量场景。
核心功能清单
| 序号 | 功能 | 说明 |
|---|---|---|
| 1 | 用户登录 | 客户端输入账号/密码,服务端校验并返回结果 |
| 2 | 拉取在线用户列表 | 客户端请求,服务端返回当前在线用户 |
| 3 | 无异常退出 | 客户端与服务端均可正常退出,释放资源 |
| 4 | 私聊 | 点对点文字通信 |
| 5 | 群聊 | 向所有在线用户广播消息 |
| 6 | 发文件 | 点对点文件传输 |
| 7 | 服务器推送新闻 | 服务端群发系统消息 |
扩展功能
离线留言:用户未在线时,消息暂存服务端,该用户登录后推送。
离线发文件:用户未在线时,文件暂存服务端,该用户登录后推送。
技术栈
| 类别 | 技术 |
|---|---|
| 语言 | Java 8+ |
| 网络通信 | java.net.Socket / ServerSocket(TCP 长连接) |
| 数据传输 | ObjectInputStream / ObjectOutputStream 传输 Serializable 对象 |
| 并发模型 | 多线程(服务端每客户端一线程;客户端一个收消息守护线程) |
| 数据存储 | HashMap 内存模拟(用户库、在线用户表) |
| 界面 | 控制台菜单(无 GUI) |
非目标
- 不实现图形界面(GUI)。
- 不使用真实数据库(MySQL 等)。
- 不引入 Netty 等高级网络框架。
- 不实现消息加密与持久化历史记录。
系统架构
C/S 架构总览

通信模型
- 传输层:TCP 长连接。客户端启动后与服务端建立一次 Socket 连接,整个会话期间保持该连接不断开(退出时关闭)。
- 应用层:所有通信以
Message对象为载体,通过对象流读写。一条请求/响应即一次writeObject/readObject。 - 方向:客户端 → 服务端为请求;服务端 → 客户端为响应或推送。
线程模型
- 服务端:主线程循环
accept(),每接入一个客户端就创建一个ClientConnectionThread专门处理该客户端的消息;另起一个SendNewsToAllService线程负责推送新闻。 - 客户端:主线程负责菜单交互与发送消息;登录成功后启动一个
ServerConnectionThread守护线程,持续接收服务端推送/转发的消息。
模块划分
| 模块 | 包 | 职责 |
|---|---|---|
| 公共模块 | com.qq.common | 定义通信协议(Message、MessageType、User),客户端与服务端共用 |
| 服务端模块 | com.qq.server | 监听连接、校验登录、消息转发、在线用户管理、新闻推送 |
| 客户端模块 | com.qq.client | 菜单交互、发送各类请求、接收并展示消息、文件收发 |
通信协议设计
消息载体
所有通信数据封装在 Message 类中,该类实现 Serializable,通过对象流传输。
消息类型常量表
| 常量名 | 值 | 方向 | 含义 |
|---|---|---|---|
MESSAGE_LOGIN | 1 | C→S | 登录请求 |
MESSAGE_LOGIN_SUCCEED | 2 | S→C | 登录成功 |
MESSAGE_LOGIN_FAIL | 3 | S→C | 登录失败 |
MESSAGE_COMM_MES | 4 | C→S→C | 私聊消息 |
MESSAGE_GET_ONLINE_FRIEND | 5 | C→S | 请求在线用户列表 |
MESSAGE_RET_ONLINE_FRIEND | 6 | S→C | 返回在线用户列表 |
MESSAGE_CLIENT_EXIT | 7 | C→S | 客户端退出 |
MESSAGE_TO_ALL_MES | 8 | C→S→C | 群聊消息 |
MESSAGE_FILE_MES | 9 | C→S→C | 文件传输 |
MESSAGE_SERVER_MES | 10 | S→C | 服务端推送新闻 |
各操作报文格式
下表描述各操作中
Message对象的字段填充约定。未列出字段为空/默认。
登录
| 阶段 | type | sender | content | getter |
|---|---|---|---|---|
| 请求 | MESSAGE_LOGIN(1) | 用户 ID | 密码 | — |
| 成功响应 | MESSAGE_LOGIN_SUCCEED(2) | — | — | — |
| 失败响应 | MESSAGE_LOGIN_FAIL(3) | — | — | — |
说明:登录请求将密码放在
content字段传输(教学项目简化处理)。
拉取在线用户列表
| 阶段 | type | content |
|---|---|---|
| 请求 | MESSAGE_GET_ONLINE_FRIEND(5) | — |
| 响应 | MESSAGE_RET_ONLINE_FRIEND(6) | "100 200 300"(空格分隔的用户 ID) |
私聊
| 阶段 | type | sender | getter | content | sendTime |
|---|---|---|---|---|---|
| 请求/转发 | MESSAGE_COMM_MES(4) | 发送者 ID | 接收者 ID | 文字内容 | 时间字符串 |
群聊
| 阶段 | type | sender | getter | content | sendTime |
|---|---|---|---|---|---|
| 请求/转发 | MESSAGE_TO_ALL_MES(8) | 发送者 ID | "all" | 文字内容 | 时间字符串 |
文件传输
| 阶段 | type | sender | getter | src | dest | fileBytes |
|---|---|---|---|---|---|---|
| 请求/转发 | MESSAGE_FILE_MES(9) | 发送者 ID | 接收者 ID | 源文件路径 | 目标保存路径 | 文件字节数组 |
客户端退出
| 阶段 | type |
|---|---|
| 请求 | MESSAGE_CLIENT_EXIT(7) |
服务器推送新闻
| 阶段 | type | sender | content | sendTime |
|---|---|---|---|---|
| 推送 | MESSAGE_SERVER_MES(10) | "服务端" | 新闻内容 | 时间字符串 |
公共模块设计
包名:
com.qq.common,客户端与服务端共用,需保证两侧类定义与serialVersionUID完全一致。
MessageType
消息类型常量定义,以 interface 形式提供静态常量。
package com.qq.common;
/**
* 消息类型常量定义
*/
public interface MessageType {
int MESSAGE_LOGIN = 1; // 登录请求
int MESSAGE_LOGIN_SUCCEED = 2; // 登录成功
int MESSAGE_LOGIN_FAIL = 3; // 登录失败
int MESSAGE_COMM_MES = 4; // 私聊消息
int MESSAGE_GET_ONLINE_FRIEND = 5; // 请求在线用户列表
int MESSAGE_RET_ONLINE_FRIEND = 6; // 返回在线用户列表
int MESSAGE_CLIENT_EXIT = 7; // 客户端退出
int MESSAGE_TO_ALL_MES = 8; // 群聊消息
int MESSAGE_FILE_MES = 9; // 文件传输
int MESSAGE_SERVER_MES = 10; // 服务端推送新闻
}Message
通信数据载体,实现 Serializable。
package com.qq.common;
import java.io.Serializable;
/**
* 通信消息封装类,客户端与服务端之间传输的统一数据格式
*/
public class Message implements Serializable {
private static final long serialVersionUID = 1L;
private int type; // 消息类型,取值见 MessageType
private String sender; // 发送者ID
private String getter; // 接收者ID(群聊为 "all")
private String content; // 消息内容(登录时存密码、在线列表时存用户ID串)
private String sendTime; // 发送时间
private String src; // 文件传输:源文件路径
private String dest; // 文件传输:目标保存路径
private byte[] fileBytes;// 文件传输:文件字节数组
// 省略所有字段的 getter / setter
// int getType() / void setType(int)
// String getSender() / void setSender(String)
// String getGetter() / void setGetter(String)
// String getContent() / void setContent(String)
// String getSendTime() / void setSendTime(String)
// String getSrc() / void setSrc(String)
// String getDest() / void setDest(String)
// byte[] getFileBytes() / void setFileBytes(byte[])
}User
用户信息载体,登录时客户端发送给服务端校验,实现 Serializable。
package com.qq.common;
import java.io.Serializable;
/**
* 用户信息类
*/
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String userId; // 用户ID
private String passwd; // 密码
// 构造方法:User() / User(String userId, String passwd)
// String getUserId() / void setUserId(String)
// String getPasswd() / void setPasswd(String)
}服务端设计
包名:
com.qq.server
类总览
| 类 | 父类 | 职责 |
|---|---|---|
QQServer | — | 服务端主程序,监听端口、校验登录、创建并管理客户端线程 |
ClientConnectionThread | Thread | 与单个客户端通信的线程,读取并分发消息 |
ClientConnectionThreadManager | — | 静态工具类,管理所有在线客户端线程集合 |
SendNewsToAllService | Thread | 新闻推送线程,读取控制台输入并群发 |
QQServerUser | — | 模拟用户数据库 |
QQServer
package com.qq.server;
import com.qq.common.*;
/**
* 服务端主程序,监听端口、校验登录、创建并管理客户端线程
*/
public class QQServer {
private ServerSocket ss = null;
private static final int PORT = 9999;
public QQServer() {
try {
// 启动新闻推送线程
new Thread(new SendNewsToAllService()).start();
ss = new ServerSocket(PORT);
while (true) {
Socket socket = ss.accept(); // 阻塞等待连接
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
User user = (User) ois.readObject(); // 读取登录用户信息
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
Message message = new Message();
if (checkUser(user.getUserId(), user.getPasswd())) { // 校验
message.setType(MessageType.MESSAGE_LOGIN_SUCCEED);
oos.writeObject(message);
// 创建并启动该客户端的专属线程
ClientConnectionThread cct =
new ClientConnectionThread(socket, user.getUserId());
cct.start();
ClientConnectionThreadManager.addClientThread(user.getUserId(), cct);
} else {
message.setType(MessageType.MESSAGE_LOGIN_FAIL);
oos.writeObject(message);
socket.close();
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 服务端关闭时资源释放(见 7.3 无异常退出)
}
}
/** 校验用户:内部调用 QQServerUser 模拟库 */
private boolean checkUser(String userId, String passwd) {
// 调用 QQServerUser.checkUser(userId, passwd)
}
public static void main(String[] args) {
new QQServer();
}
}ClientConnectionThread
package com.qq.server;
import com.qq.common.*;
/**
* 与单个客户端通信的线程,读取并分发消息
*/
public class ClientConnectionThread extends Thread {
private Socket socket;
private String userId; // 关联的客户端用户ID
public ClientConnectionThread(Socket socket, String userId) {
this.socket = socket;
this.userId = userId;
}
@Override
public void run() {
while (true) { // 持续读取客户端消息
try {
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message message = (Message) ois.readObject();
switch (message.getType()) {
case MessageType.MESSAGE_GET_ONLINE_FRIEND:
// 返回在线用户列表(见 7.2)
break;
case MessageType.MESSAGE_COMM_MES:
// 私聊转发(见 7.4)
break;
case MessageType.MESSAGE_TO_ALL_MES:
// 群聊转发(见 7.5)
break;
case MessageType.MESSAGE_FILE_MES:
// 文件转发(见 7.6)
break;
case MessageType.MESSAGE_CLIENT_EXIT:
// 客户端退出处理(见 7.3)
return; // 结束本线程
// ...
}
} catch (Exception e) {
e.printStackTrace();
return;
}
}
}
public Socket getSocket() { return socket; }
}ClientConnectionThreadManager
package com.qq.server;
import java.util.HashMap;
import java.util.Iterator;
/**
* 静态工具类,管理所有在线客户端线程集合
*/
public class ClientConnectionThreadManager {
private static HashMap<String, ClientConnectionThread> hm = new HashMap<>();
public static void addClientThread(String userId, ClientConnectionThread cct) {
hm.put(userId, cct);
}
public static ClientConnectionThread getClientThread(String userId) {
return hm.get(userId);
}
/** 返回所有在线用户ID,空格分隔 */
public static String getOnlineUserList() {
Iterator<String> it = hm.keySet().iterator();
StringBuilder sb = new StringBuilder();
while (it.hasNext()) {
sb.append(it.next()).append(" ");
}
return sb.toString();
}
public static void removeClientThread(String userId) {
hm.remove(userId);
}
public static HashMap<String, ClientConnectionThread> getHm() {
return hm;
}
}SendNewsToAllService
package com.qq.server;
import com.qq.common.*;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.io.*;
/**
* 新闻推送线程,读取控制台输入并群发
*/
public class SendNewsToAllService implements Runnable {
@Override
public void run() {
while (true) {
// 读取服务端控制台输入的新闻内容
// 构造 MESSAGE_SERVER_MES 消息
// 遍历 ClientConnectionThreadManager.getHm() 所有线程,逐个 getSocket() 写出消息
// (见 7.7 服务器推送新闻)
}
}
}QQServerUser
package com.qq.server;
import com.qq.common.User;
import java.util.HashMap;
/**
* 模拟用户数据库
*/
public class QQServerUser {
private static HashMap<String, User> validUsers = new HashMap<>();
static {
validUsers.put("100", new User("100", "123456"));
validUsers.put("200", new User("200", "123456"));
validUsers.put("300", new User("300", "123456"));
validUsers.put("至尊宝", new User("至尊宝", "123456"));
validUsers.put("紫霞仙子", new User("紫霞仙子", "123456"));
validUsers.put("菩提老祖", new User("菩提老祖", "123456"));
}
/** 校验用户是否存在且密码正确 */
public boolean checkUser(String userId, String passwd) {
User user = validUsers.get(userId);
if (user != null && passwd.equals(user.getPasswd())) {
return true;
}
return false;
}
}默认账号:
100 / 200 / 300 / 至尊宝 / 紫霞仙子 / 菩提老祖,密码统一123456,用于多客户端联调。
客户端设计
包名:
com.qq.client
类总览
| 类 | 父类 | 职责 |
|---|---|---|
QQView | — | 客户端主程序,控制台菜单 UI 与主流程控制 |
UserClientService | — | 登录、登出、拉取在线用户列表的业务逻辑 |
MessageClientService | — | 私聊、群聊业务逻辑 |
FileClientService | — | 文件发送业务逻辑 |
ServerConnectionThread | Thread | 守护线程,持续接收服务端消息并展示 |
ServerConnectionThreadManager | — | 静态工具类,管理客户端与服务端的连接线程 |
QQView
mainMenu

secondaryMenu

package com.qq.client;
import com.qq.client.service.UserClientService;
/**
* 客户端主程序,控制台菜单 UI 与主流程控制
*/
public class QQView {
private boolean loop = true; // 控制主菜单循环
private String key = ""; // 接收用户输入
private UserClientService userClientService = new UserClientService();
private MessageClientService messageClientService = new MessageClientService();
private FileClientService fileClientService = new FileClientService();
public static void main(String[] args) {
new QQView().mainMenu();
}
/** 一级菜单:登录 */
private void mainMenu() {
while (loop) {
// 输出登录菜单:系统名称、登录、退出
// 选择"登录系统" → 调用 userClientService.checkUser() → 登录成功进入二级菜单 secondaryMenu()
// 选择"退出系统" → loop = false
}
}
/** 二级菜单:功能操作 */
private void secondaryMenu() {
while (loop) {
// 1 拉取在线用户列表 → userClientService.onlineFriendList()
// 2 群发消息 → messageClientService.sendMessageToAll()
// 3 私聊消息 → messageClientService.sendMessageToOne()
// 4 发送文件 → fileClientService.sendFile()
// 9 退出系统 → userClientService.logout()
}
}
}UserClientService
package com.qq.client;
import com.qq.common.*;
/**
* 登录、登出、拉取在线用户列表的业务逻辑
*/
public class UserClientService {
private User user = new User();
private Socket socket;
/** 用户登录 */
public boolean checkUser(String userId, String pwd) {
// 1. 创建 Socket 连接服务端 PORT=9999
// 2. 发送 User 对象
// 3. 接收 Message 响应
// - MESSAGE_LOGIN_SUCCEED:启动 ServerConnectionThread 守护线程,返回 true
// - MESSAGE_LOGIN_FAIL:关闭 socket,返回 false
}
/** 拉取在线用户列表 */
public void onlineFriendList() {
// 构造 MESSAGE_GET_ONLINE_FRIEND 发送
// 守护线程接收 MESSAGE_RET_ONLINE_FRIEND 并打印(见 7.2)
}
/** 退出登录 */
public void logout() {
// 构造 MESSAGE_CLIENT_EXIT 发送
// 关闭 socket,结束守护线程
}
}MessageClientService
package com.qq.client;
import com.qq.common.*;
/**
* 私聊、群聊业务逻辑
*/
public class MessageClientService {
/** 私聊 */
public void sendMessageToOne(String senderId, String getterId, String content) {
// 构造 MESSAGE_COMM_MES 消息,填充 sender/getter/content/sendTime
// 通过当前连接线程的 socket 发送(见 7.4)
}
/** 群聊 */
public void sendMessageToAll(String senderId, String content) {
// 构造 MESSAGE_TO_ALL_MES 消息,getter="all",填充 sender/content/sendTime
// 发送(见 7.5)
}
}FileClientService
package com.qq.client;
import com.qq.common.*;
/**
* 文件发送业务逻辑
*/
public class FileClientService {
/** 发送文件 */
public void sendFile(String senderId, String getterId, String src, String dest) {
// 1. 读取 src 文件为 byte[]
// 2. 构造 MESSAGE_FILE_MES 消息,填充 sender/getter/src/dest/fileBytes
// 3. 发送(见 7.6)
}
}ServerConnectionThread
package com.qq.client;
import com.qq.common.*;
/**
* 客户端连接服务端的线程类
*/
public class ServerConnectionThread extends Thread {
private Socket socket;
public ServerConnectionThread(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
while (true) { // 持续接收服务端消息
try {
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message message = (Message) ois.readObject(); // 阻塞读取服务器返回的消息
switch (message.getType()) {
case MessageType.MESSAGE_RET_ONLINE_FRIEND:
// 打印在线用户列表(见 7.2)
break;
case MessageType.MESSAGE_COMM_MES:
// 显示私聊消息(见 7.4)
break;
case MessageType.MESSAGE_TO_ALL_MES:
// 显示群聊消息(见 7.5)
break;
case MessageType.MESSAGE_FILE_MES:
// 保存文件到 dest(见 7.6)
break;
case MessageType.MESSAGE_SERVER_MES:
// 显示服务端推送新闻(见 7.7)
break;
// ...
}
} catch (Exception e) {
e.printStackTrace();
return;
}
}
}
public Socket getSocket() { return socket; }
}ServerConnectionThreadManager
package com.qq.client;
import com.qq.client.service.ServerConnectionThread;
import java.util.HashMap;
/**
* 管理客户端连接服务端的线程的类
*/
public class ServerConnectionThreadManager {
private static HashMap<String, ServerConnectionThread> hm = new HashMap<>();
public static void addServerConnectionThread(String userId, ServerConnectionThread ccst) {
hm.put(userId, ccst);
}
public static ServerConnectionThread getServerConnectionThread(String userId) {
return hm.get(userId);
}
public static void removeServerConnectionThread(String userId) {
hm.remove(userId);
}
}功能模块详细设计
用户登录
关键代码骨架(客户端发送登录):
// UserClientService.checkUser()
user.setUserId(userId);
user.setPasswd(pwd);
socket = new Socket("127.0.0.1", 9999);
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
oos.writeObject(user); // 发送 User
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message msg = (Message) ois.readObject(); // 读取响应
if (msg.getType() == MessageType.MESSAGE_LOGIN_SUCCEED) {
ServerConnectionThread ccst = new ServerConnectionThread(socket);
ccst.start();
ServerConnectionThreadManager.addServerConnectionThread(userId, ccst);
return true;
} else {
socket.close();
return false;
}拉取在线用户列表
服务端处理骨架:
case MessageType.MESSAGE_GET_ONLINE_FRIEND:
Message msg = new Message();
msg.setType(MessageType.MESSAGE_RET_ONLINE_FRIEND);
msg.setContent(ClientConnectionThreadManager.getOnlineUserList());
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
oos.writeObject(msg);
break;无异常退出

客户端退出:
二级菜单选择"退出系统" →
UserClientService.logout()发送MESSAGE_CLIENT_EXIT→ 关闭 socket。守护线程因
readObject抛异常而自然结束。javapublic void logout() { Message message = new Message(); message.setType(MessageType.MESSAGE_CLIENT_EXIT); message.setSender(user.getUserId()); ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream()); oos.writeObject(message); socket.close(); // 关闭连接 }
服务端退出处理:
收到
MESSAGE_CLIENT_EXIT后,从ClientConnectionThreadManager移除该用户、关闭对应 socket、结束该客户端线程。javacase MessageType.MESSAGE_CLIENT_EXIT: ClientConnectionThreadManager.removeClientThread(userId); socket.close(); return; // 结束本线程 run()
服务端主程序退出:监听循环可设计条件退出,finally 中关闭 ServerSocket,并遍历关闭所有客户端 socket 与推送线程。
私聊
服务端转发骨架:
case MessageType.MESSAGE_COMM_MES:
String getterId = message.getGetter();
ClientConnectionThread cct = ClientConnectionThreadManager.getClientThread(getterId);
if (cct != null) {
ObjectOutputStream oos = new ObjectOutputStream(cct.getSocket().getOutputStream());
oos.writeObject(message); // 原样转发
}
// 否则接收者离线(扩展功能处理)
break;客户端接收骨架:
case MessageType.MESSAGE_COMM_MES:
System.out.println("\n" + message.getSender() + " 对 "
+ message.getGetter() + " 说: " + message.getContent()
+ " 时间:" + message.getSendTime());
break;群聊
服务端群发骨架:
case MessageType.MESSAGE_TO_ALL_MES:
HashMap<String, ClientConnectionThread> all = ClientConnectionThreadManager.getHm();
for (String uid : all.keySet()) {
// 可选择不回发给发送者自身;默认群发含自身
ObjectOutputStream oos = new ObjectOutputStream(
all.get(uid).getSocket().getOutputStream());
oos.writeObject(message);
}
break;发文件
客户端发送骨架:
public void sendFile(String senderId, String getterId, String src, String dest) {
// 读取文件
FileInputStream fis = new FileInputStream(src);
byte[] fileBytes = new byte[(int) new File(src).length()];
fis.read(fileBytes);
fis.close();
Message message = new Message();
message.setType(MessageType.MESSAGE_FILE_MES);
message.setSender(senderId);
message.setGetter(getterId);
message.setSrc(src);
message.setDest(dest);
message.setFileBytes(fileBytes);
// 通过当前连接线程的 socket 发送
ObjectOutputStream oos = new ObjectOutputStream(
ServerConnectionThreadManager.getServerConnectionThread(senderId)
.getSocket().getOutputStream());
oos.writeObject(message);
}客户端接收骨架:
case MessageType.MESSAGE_FILE_MES:
System.out.println("\n" + message.getSender() + " 给 "
+ message.getGetter() + " 发文件: " + message.getSrc()
+ " 到我的电脑目录 " + message.getDest());
FileOutputStream fos = new FileOutputStream(message.getDest(), false);
fos.write(message.getFileBytes());
fos.close();
break;服务器推送新闻
推送线程骨架:
public void run() {
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("请输入服务器推送新闻/公告(exit 退出推送):");
String news = scanner.next();
if ("exit".equals(news)) break;
Message message = new Message();
message.setType(MessageType.MESSAGE_SERVER_MES);
message.setSender("服务端");
message.setContent(news);
message.setSendTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
.format(new Date()));
HashMap<String, ClientConnectionThread> all = ClientConnectionThreadManager.getHm();
for (String uid : all.keySet()) {
ObjectOutputStream oos = new ObjectOutputStream(
all.get(uid).getSocket().getOutputStream());
oos.writeObject(message);
}
}
}数据结构设计
模拟用户库
HashMap<String, User> validUsers; // 服务端,key=userId- 启动时静态初始化,内置若干测试账号。
- 仅用于登录校验,不持久化。
在线用户表
HashMap<String, ClientConnectionThread> hm; // key=userId, value=该客户端通信线程- 登录成功时
put,退出时remove。 - 提供
getOnlineUserList()拼接所有 key 返回。 - 并发注意:
HashMap非线程安全。教学项目中消息量低、并发写冲突概率小,可保持HashMap;若需加固,可替换为ConcurrentHashMap,或在ClientConnectionThreadManager方法上加synchronized。
客户端连接管理表
HashMap<String, ServerConnectionThread> hm; // key=userId, value=与服务端的连接线程- 单客户端通常仅一个连接,通过 userId 取到对应线程的 socket 发送消息。
Message 字段矩阵
| 字段 | 类型 | 登录 | 在线列表 | 私聊 | 群聊 | 文件 | 推送 | 退出 |
|---|---|---|---|---|---|---|---|---|
| type | int | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| sender | String | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ |
| getter | String | — | — | ✅ | "all" | ✅ | — | — |
| content | String | 密码 | 用户 ID 串 | 文字 | 文字 | — | 新闻 | — |
| sendTime | String | — | — | ✅ | ✅ | — | ✅ | — |
| src | String | — | — | — | — | ✅ | — | — |
| dest | String | — | — | — | — | ✅ | — | — |
| fileBytes | byte[] | — | — | — | — | ✅ | — | — |
项目目录结构
QQ/
├── 开发文档.md
└── src/
└── com/
└── qq/
├── common/
│ ├── Message.java
│ ├── MessageType.java
│ └── User.java
├── server/
│ ├── QQServer.java
│ ├── QQServerUser.java
│ ├── ClientConnectionThread.java
│ ├── ClientConnectionThreadManager.java
│ └── SendNewsToAllService.java
└── client/
├── QQView.java
├── UserClientService.java
├── MessageClientService.java
├── FileClientService.java
├── ServerConnectionThread.java
└── ServerConnectionThreadManager.java包职责一览
| 包 | 类数 | 职责 |
|---|---|---|
com.qq.common | 3 | 通信协议与数据载体,双端共享 |
com.qq.server | 5 | 服务端监听、校验、转发、管理、推送 |
com.qq.client | 6 | 客户端菜单、业务服务、守护线程、连接管理 |
扩展功能设计
离线留言
场景:私聊时接收者不在线,消息需暂存,待其登录后推送。
设计方案:
服务端新增
HashMap<String, List<Message>> offlineMessages,key=接收者 ID。ClientConnectionThread处理MESSAGE_COMM_MES时,若getClientThread(getter)为null(离线),将消息add到offlineMessages.get(getter)。客户端登录成功、
ClientConnectionThread启动后,先检查offlineMessages.get(userId),若有积压,逐条通过该 socket 写出给客户端,然后清空列表。
离线发文件
场景:发文件时接收者不在线,文件需暂存,待其登录后推送。
设计方案:
服务端新增
HashMap<String, List<Message>> offlineFiles,key=接收者 ID。处理
MESSAGE_FILE_MES时,若接收者离线,将携带fileBytes的Message存入offlineFiles。接收者登录后,由其线程读取并推送积压文件,随后清空。
注意:
fileBytes可能较大,大量离线文件会占用服务端内存;教学项目可限定大小或数量。
测试方案
测试环境
- 编译并运行服务端一次,客户端运行多份(不同账号)模拟多用户。
- 本机测试:服务端
127.0.0.1:9999,多个客户端窗口分别登录不同账号。
集成测试用例
| 用例 | 操作 | 预期结果 |
|---|---|---|
| TC-01 登录成功 | 账号 100/密码 123456 登录 | 返回登录成功,进入二级菜单 |
| TC-02 登录失败 | 账号 100/密码 wrong 登录 | 返回登录失败,提示重新输入 |
| TC-03 拉取在线列表 | 登录后选"拉取在线用户" | 显示当前所有在线用户 ID |
| TC-04 私聊 | A 私聊 B 内容"hello" | B 收到"A 对 B 说: hello 时间:..." |
| TC-05 私聊离线 | A 私聊不在线的 C | 不报错(扩展:暂存留言) |
| TC-06 群聊 | A 群发"大家好" | 所有在线用户(含 A)收到消息 |
| TC-07 发文件 | A 向 B 发 test.txt 到 dest.txt | B 侧生成 dest.txt,内容一致 |
| TC-08 客户端退出 | A 选"退出系统" | A 断开、线程结束、不影响其他用户 |
| TC-09 服务端推送 | 服务端输入新闻 | 所有在线客户端收到推送并显示 |
| TC-10 多用户并发 | 3 客户端同时在线相互私聊/群聊 | 消息各自正确送达,无串台 |
验收标准
- 七项核心功能全部按预期工作,无异常栈输出。
- 客户端退出不影响服务端与其他客户端。
- 文件传输后内容与源文件逐字节一致。
部署与运行
编译
假设源码位于 src/,在项目根目录执行:
javac -d out -sourcepath src src/com/qq/server/QQServer.java
javac -d out -sourcepath src src/com/qq/client/QQView.java编译产物输出到 out/ 目录。
运行
第一步:启动服务端
java -cp out com.qq.server.QQServer服务端监听 9999 端口,等待客户端连接。
第二步:启动客户端(可多个)
java -cp out com.qq.client.view.QQView每开一个窗口即可用一个账号登录。推荐至少开 2-3 个客户端窗口联调。
端口与账号说明
- 服务端口:
9999(如冲突可改QQServer.PORT与客户端连接端口)。 - 测试账号:
100 / 200 / 300 / 至尊宝 / 紫霞仙子 / 菩提老祖,密码均123456。 - 字符编码:源码与控制台使用 UTF-8;Windows 控制台中文若乱码,运行前执行
chcp 65001。
假设与约定
- 文档语言为中文;技术路线严格对齐参考笔记(纯 Java SE,无 Netty、无数据库、控制台 UI)。
- 默认服务端口
9999,字符编码 UTF-8,账号库为硬编码内存数据,重启即重置。 - 模拟数据库用
HashMap,非线程安全;教学场景可接受,加固时可换ConcurrentHashMap。 - 扩展功能(离线留言 / 离线文件)以设计方案形式给出,标注为可选实现,不强制纳入核心交付。
Message的serialVersionUID在客户端与服务端必须一致,否则反序列化失败。- 文档仅为开发规格说明,不包含可编译的完整
.java源文件;实现者按本文档类设计与代码骨架补全方法体即可。